Java 26-Day Course - Day 26: Mini Project - Spring Boot Todo API

Day 26: Mini Project - Spring Boot Todo API

It’s time to wrap up the journey. We’ll use everything learned — Java fundamentals, OOP, collections, streams, exception handling, JDBC, and Spring Boot — to build a complete Todo REST API. The goal is a production-ready project you can use right away.

Project Setup and Domain Model

Design the project skeleton and core domain.

// build.gradle.kts
/*
plugins {
    java
    id("org.springframework.boot") version "3.2.0"
    id("io.spring.dependency-management") version "1.1.4"
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    runtimeOnly("com.h2database:h2")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}
*/

// Domain entity: using JPA
import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "todos")
public class Todo {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String title;

    @Column(length = 500)
    private String description;

    @Column(nullable = false)
    private boolean completed = false;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Priority priority = Priority.MEDIUM;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @PrePersist
    void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }

    @PreUpdate
    void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    // Constructor, getters, setters omitted
    public enum Priority { HIGH, MEDIUM, LOW }
}

Repository and Service Layers

Implement Spring Data JPA and business logic.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

// Repository: Spring Data JPA auto-generates the implementation
@Repository
interface TodoRepository extends JpaRepository<Todo, Long> {
    List<Todo> findByCompleted(boolean completed);
    List<Todo> findByPriority(Todo.Priority priority);
    List<Todo> findByTitleContainingIgnoreCase(String keyword);

    @Query("SELECT t FROM Todo t WHERE t.completed = false ORDER BY " +
           "CASE t.priority WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 ELSE 3 END")
    List<Todo> findPendingOrderByPriority();

    long countByCompleted(boolean completed);
}

// DTOs
record CreateTodoRequest(
    @jakarta.validation.constraints.NotBlank(message = "Title is required")
    @jakarta.validation.constraints.Size(max = 100) String title,
    @jakarta.validation.constraints.Size(max = 500) String description,
    Todo.Priority priority
) {}

record UpdateTodoRequest(
    @jakarta.validation.constraints.NotBlank String title,
    String description,
    Todo.Priority priority,
    Boolean completed
) {}

record TodoResponse(Long id, String title, String description,
                    boolean completed, String priority,
                    String createdAt, String updatedAt) {
    static TodoResponse from(Todo todo) {
        return new TodoResponse(
            todo.getId(), todo.getTitle(), todo.getDescription(),
            todo.isCompleted(), todo.getPriority().name(),
            todo.getCreatedAt().toString(),
            todo.getUpdatedAt() != null ? todo.getUpdatedAt().toString() : null
        );
    }
}

record TodoStats(long total, long completed, long pending,
                 double completionRate) {}

// Service
@Service
@Transactional
class TodoService {
    private final TodoRepository repository;

    TodoService(TodoRepository repository) {
        this.repository = repository;
    }

    TodoResponse create(CreateTodoRequest request) {
        Todo todo = new Todo();
        todo.setTitle(request.title());
        todo.setDescription(request.description());
        todo.setPriority(request.priority() != null ?
                         request.priority() : Todo.Priority.MEDIUM);
        return TodoResponse.from(repository.save(todo));
    }

    @Transactional(readOnly = true)
    List<TodoResponse> findAll(Boolean completed, Todo.Priority priority,
                                String keyword) {
        List<Todo> todos;
        if (keyword != null && !keyword.isBlank()) {
            todos = repository.findByTitleContainingIgnoreCase(keyword);
        } else if (completed != null) {
            todos = repository.findByCompleted(completed);
        } else if (priority != null) {
            todos = repository.findByPriority(priority);
        } else {
            todos = repository.findAll();
        }
        return todos.stream().map(TodoResponse::from).toList();
    }

    @Transactional(readOnly = true)
    TodoResponse findById(Long id) {
        return repository.findById(id)
            .map(TodoResponse::from)
            .orElseThrow(() -> new TodoNotFoundException(id));
    }

    TodoResponse update(Long id, UpdateTodoRequest request) {
        Todo todo = repository.findById(id)
            .orElseThrow(() -> new TodoNotFoundException(id));
        todo.setTitle(request.title());
        if (request.description() != null) todo.setDescription(request.description());
        if (request.priority() != null) todo.setPriority(request.priority());
        if (request.completed() != null) todo.setCompleted(request.completed());
        return TodoResponse.from(repository.save(todo));
    }

    TodoResponse toggleComplete(Long id) {
        Todo todo = repository.findById(id)
            .orElseThrow(() -> new TodoNotFoundException(id));
        todo.setCompleted(!todo.isCompleted());
        return TodoResponse.from(repository.save(todo));
    }

    void delete(Long id) {
        if (!repository.existsById(id)) throw new TodoNotFoundException(id);
        repository.deleteById(id);
    }

    @Transactional(readOnly = true)
    TodoStats getStats() {
        long total = repository.count();
        long completed = repository.countByCompleted(true);
        long pending = total - completed;
        double rate = total > 0 ? (double) completed / total * 100 : 0;
        return new TodoStats(total, completed, pending, Math.round(rate * 10) / 10.0);
    }
}

Controller and Global Exception Handling

REST endpoints and unified error handling.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import jakarta.validation.Valid;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/todos")
class TodoController {
    private final TodoService service;

    TodoController(TodoService service) { this.service = service; }

    @PostMapping
    ResponseEntity<TodoResponse> create(@Valid @RequestBody CreateTodoRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req));
    }

    @GetMapping
    List<TodoResponse> findAll(
            @RequestParam(required = false) Boolean completed,
            @RequestParam(required = false) Todo.Priority priority,
            @RequestParam(required = false) String keyword) {
        return service.findAll(completed, priority, keyword);
    }

    @GetMapping("/{id}")
    TodoResponse findById(@PathVariable Long id) {
        return service.findById(id);
    }

    @PutMapping("/{id}")
    TodoResponse update(@PathVariable Long id,
                        @Valid @RequestBody UpdateTodoRequest req) {
        return service.update(id, req);
    }

    @PatchMapping("/{id}/toggle")
    TodoResponse toggle(@PathVariable Long id) {
        return service.toggleComplete(id);
    }

    @DeleteMapping("/{id}")
    ResponseEntity<Void> delete(@PathVariable Long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/stats")
    TodoStats stats() { return service.getStats(); }
}

// Exception class
class TodoNotFoundException extends RuntimeException {
    TodoNotFoundException(Long id) { super("Todo not found (ID: " + id + ")"); }
}

// Global exception handling
@RestControllerAdvice
class GlobalExceptionHandler {
    record ErrorBody(int status, String error, String message, String timestamp) {}

    @ExceptionHandler(TodoNotFoundException.class)
    ResponseEntity<ErrorBody> notFound(TodoNotFoundException e) {
        return ResponseEntity.status(404)
            .body(new ErrorBody(404, "Not Found", e.getMessage(),
                                LocalDateTime.now().toString()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ErrorBody> validation(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
            .map(err -> err.getField() + ": " + err.getDefaultMessage())
            .reduce((a, b) -> a + "; " + b).orElse("Validation failed");
        return ResponseEntity.badRequest()
            .body(new ErrorBody(400, "Bad Request", msg,
                                LocalDateTime.now().toString()));
    }

    @ExceptionHandler(Exception.class)
    ResponseEntity<ErrorBody> general(Exception e) {
        return ResponseEntity.status(500)
            .body(new ErrorBody(500, "Internal Server Error", e.getMessage(),
                                LocalDateTime.now().toString()));
    }
}

Test Code

Write API integration tests.

import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.hamcrest.Matchers.*;

@SpringBootTest
@AutoConfigureMockMvc
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class TodoApiIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @Order(1)
    @DisplayName("POST /api/todos - Create a todo")
    void createTodo() throws Exception {
        String json = """
            {"title": "Study Java", "description": "Complete the 26-day course", "priority": "HIGH"}
            """;
        mockMvc.perform(post("/api/todos")
                .contentType(MediaType.APPLICATION_JSON)
                .content(json))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.title").value("Study Java"))
            .andExpect(jsonPath("$.completed").value(false))
            .andExpect(jsonPath("$.priority").value("HIGH"));
    }

    @Test
    @Order(2)
    @DisplayName("GET /api/todos - Get all todos")
    void findAll() throws Exception {
        mockMvc.perform(get("/api/todos"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1))));
    }

    @Test
    @Order(3)
    @DisplayName("GET /api/todos/{id} - Returns 404 for non-existent ID")
    void findByIdNotFound() throws Exception {
        mockMvc.perform(get("/api/todos/9999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.status").value(404));
    }

    @Test
    @Order(4)
    @DisplayName("POST /api/todos - Returns 400 when title is missing")
    void createWithoutTitle() throws Exception {
        String json = """
            {"description": "No title"}
            """;
        mockMvc.perform(post("/api/todos")
                .contentType(MediaType.APPLICATION_JSON)
                .content(json))
            .andExpect(status().isBadRequest());
    }

    @Test
    @Order(5)
    @DisplayName("GET /api/todos/stats - Get statistics")
    void getStats() throws Exception {
        mockMvc.perform(get("/api/todos/stats"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.total").isNumber())
            .andExpect(jsonPath("$.completionRate").isNumber());
    }
}

Course Summary and Next Steps

// Summary of what we learned over the course:
//
// Week 1 (Day 1-7): Java Fundamentals
//   - Environment setup, variables/types, operators, conditionals, loops, arrays, strings
//
// Week 2 (Day 8-14): Object-Oriented Programming
//   - Methods, classes/objects, encapsulation, inheritance, polymorphism
//   - Abstract classes, interfaces, inner/anonymous classes
//
// Week 3 (Day 15-21): Core APIs and Functional Programming
//   - Exception handling, collections (List, Set, Map), generics
//   - Lambdas, Stream API, Optional
//
// Week 4 (Day 22-26): Real-World Development
//   - File I/O, JDBC, build tools, JUnit 5
//   - Spring Boot, REST API, mini project

// Next steps roadmap:
// 1. Spring Security (authentication/authorization)
// 2. JPA/Hibernate deep dive
// 3. Docker + deployment
// 4. Messaging (Kafka, RabbitMQ)
// 5. Microservices architecture

public class CourseComplete {
    public static void main(String[] args) {
        System.out.println("Congratulations! You've completed the Java 26-Day Course!");
        System.out.println("Now go build real-world projects.");
    }
}

Today’s Exercises

  1. Add Categories: Add a category field (Work, Personal, Study, etc.) to Todo, and implement APIs for filtering by category and providing per-category statistics.

  2. Due Date Feature: Add a dueDate field to Todo. Implement a /api/todos/overdue endpoint to retrieve overdue items, and add the ability to sort by due date.

  3. Full Project Build: Integrate all the code into a single Spring Boot project. Build with ./gradlew build, pass all tests with ./gradlew test, run with ./gradlew bootRun, and test all APIs using curl or Postman.

Was this article helpful?